feat(mcp): MCP 2026-07-28 stateless path + server/discover (slice 1+4) - #63
Conversation
Adds the MCP 2026-07-28 revision (SEP-2575) alongside the existing initialize-era path, routed on the MCP-Protocol-Version header: - no header, or an initialize-era version (2025-06-18+ clients send their negotiated version) -> the legacy session path, byte-identical; - 2026-07-28 -> a stateless path: no sessions read or minted, per-request _meta validation (io.modelcontextprotocol/* keys as single-place constants), SEP-2243 mirrored-header validation (Mcp-Method always, Mcp-Name for tools/call incl. the Base64 sentinel format), server/discover carrying supportedVersions + capabilities + serverInfo + instructions (ECA-13 advisories port 1:1), tools/list with SEP-2549 ttlMs/cacheScope, and initialize/ping falling to method-not-found; - any other version -> -32004 with the supported list (HTTP 400). Transport statuses per spec: -32601 -> 404, -32603 -> 500, other protocol errors -> 400. Batching is rejected on the new path (removed from the protocol in 2025-06-18). With no per-instance session state on the protocol path, any request can land on any fleet instance. Verified against the SEP-2575 text and the modelcontextprotocol.io transport/caching pages (2026-08-03); the 2026-07-28 schema.ts is not yet published, hence wire literals live as constants in types.rs with a verification note. mcp_instructions.md reviewed for stateless clients: NO-OP, the text is transport-agnostic (no initialize references). Deviation from the brief: integration fixtures live as in-module tests (negotiation matrix, header and _meta validation, discover contract) because loomem-server has no lib target for external HTTP-layer tests, the same pattern documented in tests/feedback_mcp_test.rs. Signed-off-by: Łukasz Gumowski <lukasz.gumowski@gmail.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
On the stateless path, valid JSON that fails to deserialize into a JSON-RPC request object was answered with -32700 Parse error, but JSON parsing had already succeeded at that point - JSON-RPC 2.0 reserves -32700 for invalid JSON text and -32600 for an invalid Request object. Extracted parse_single_request() so the branch is unit-testable and the extractable id, when present, is echoed in the error response instead of null. Addresses Greptile P1 on #63. The legacy path keeps its historical behavior by design (byte-identical rule). Signed-off-by: Łukasz Gumowski <lukasz.gumowski@gmail.com>
request_id_of preserved any id value verbatim, so a -32600 (or -32004) response could echo an object, array or boolean id - types JSON-RPC 2.0 does not allow for identifiers, where null is mandated when the id cannot be trusted. Strict clients may reject such a response. Only string and number ids are echoed now; everything else maps to null. Addresses the Greptile follow-up P1 on #63. Signed-off-by: Łukasz Gumowski <lukasz.gumowski@gmail.com>
A request carrying an object, array or boolean id deserialized fine (JsonRpcRequest.id is an unrestricted JSON value) and reached dispatch, returning 200 with the illegal id echoed. JSON-RPC 2.0 restricts ids to strings, numbers and null, so such a request is now answered -32600 with a null response id before header or _meta validation runs. An explicit null id keeps deserializing to None and stays a notification. Addresses the Greptile round-3 P1 on #63. Signed-off-by: Łukasz Gumowski <lukasz.gumowski@gmail.com>
Serde collapses "id": null into the same None as an omitted id, so the stateless handler answered 202 and never dispatched. JSON-RPC 2.0 makes only the omitted id a notification; a present null id is a discouraged but legal request whose response id is null. Presence is now captured from the raw body before deserialization and restored as Some(Value::Null), which flows through validation, dispatch and the response id. The shared JsonRpcRequest type and the legacy path are untouched. Addresses the Greptile round-4 P1 pair on #63. Signed-off-by: Łukasz Gumowski <lukasz.gumowski@gmail.com>
| // Notifications: accept with 202 and no body. Header requirements for | ||
| // notification POSTs are undefined in this revision, so none are enforced. | ||
| let Some(request_id) = request.id.clone() else { | ||
| return StatusCode::ACCEPTED.into_response(); | ||
| }; |
There was a problem hiding this comment.
Stateless notifications bypass required validation
Requests without an id return 202 Accepted before the handler validates Mcp-Method, the required _meta fields, the JSON-RPC version, or whether the method exists. As a result, an invalid 2026-07-28 notification—for example, one missing both Mcp-Method and _meta—is silently acknowledged instead of receiving the required HTTP error. Validate the request shape, mirrored headers, metadata, and method before returning 202; only accepted notifications should receive an empty 202 response.
Artifacts
Source of the id-less stateless notification reproduction
- The executable reproduction reads the checked-out handler and asserts its notification return precedes every validator, ending with the protocol violation it exercises.
Observed id-less malformed stateless notification behavior
- The executed reproduction reports HTTP 202 with an empty body for missing `Mcp-Method`/`_meta` and wrong JSON-RPC version, ending with the confirmed bypass.
Stateless handler source showing notification branch before validators
- Captured numbered source shows the early notification return at lines 76-80 and header/meta validators only at lines 81-86, ending with the precise defective ordering.
Focused Rust stateless test compilation failure
- The attempted focused `cargo test` exits 101 because the environment lacks `llvm-config` and `libclang`, ending with the reason a compiled runtime request could not be run.
Pre-stateless-path source availability check
- The pre-feature commit has no stateless handler file, ending with confirmation that no comparable before-path execution exists.
There was a problem hiding this comment.
Deferred by design, with a spec citation: the 2026-07-28 Streamable HTTP transport page states the core protocol defines no client-to-server notifications over this transport, and that header requirements for notification POSTs are not defined by this revision - acceptance of a notification is left to the server (if the server accepts it, 202 with no body). Malformed notification bodies already fail closed: anything that is not a valid JSON-RPC object is rejected with -32600 at parse time before the 202 branch. Tightening beyond the spec here would risk rejecting future benign notifications with no compliance gain; happy to revisit when the spec pins these rules down.
There was a problem hiding this comment.
You're right — re-reading the flow, parse_single_request is the gate. A structurally invalid object (bad jsonrpc version, invalid id type, missing method) fails there before the id check is ever reached. Only a well-formed JSON-RPC notification (valid object, valid jsonrpc, omitted id) reaches the 202 return. With the spec explicitly leaving Mcp-Method and _meta undefined for notification POSTs, the behavior is correct as written. Withdrawing the comment.
What
Slice 1 (plus the slice-4 items that are compliance-required) of the MCP 2026-07-28 adoption brief: the stateless protocol path (SEP-2575) served alongside the untouched legacy path.
Routing on
MCP-Protocol-Version:-32004+{supported, requested}data (HTTP 400)On the stateless path:
AuthContextper request (it already did). Any request can hit any fleet instance on the protocol path too.server/discover(SEP-2575):supportedVersions,capabilities,serverInfo,instructions— the ECA-13 dynamic advisories port 1:1 (discover has the same per-request auth), covered by a contract test._metavalidation: the three requiredio.modelcontextprotocol/*fields; missing →-32602, version conflicting with the header →-32020(both HTTP 400). Key names are single-place constants intypes.rs.Mcp-Methodrequired on every request,Mcp-Nameontools/call— including the=?base64?…?=sentinel decoding (minimal in-tree decoder, same no-new-deps posture as the hand-rolled SHA-256 inoauth.rs); missing/mismatch →-32020+ 400 per the transport spec MUST.tools/listcarries SEP-2549ttlMs/cacheScope— REQUIRED fields in this revision: 1h TTL (the list is static per config),privatescope (authenticated endpoint).initialize/ping→ method-not-found (removed in this revision);-32601→ HTTP 404,-32603→ 500, other protocol errors → 400. Batching rejected (left the protocol in 2025-06-18); notifications → 202.Deliberately NOT here
-32003(missing_client_capability) ships as a reserved constructor: no Loomem path requires client capabilities today (no sampling/elicitation/roots).mcp_instructions.mdreview for stateless clients: NO-OP — the text is transport-agnostic (no initialize/handshake references).Verification note
Grounded in the full SEP-2575 text plus the modelcontextprotocol.io transport/caching pages (fetched 2026-08-03). The 2026-07-28
schema.tsis still unpublished; all wire literals live as constants intypes.rsso a published-schema correction lands in one place.Gate
cargo fmt --checkcargo clippy --workspace -- -D warningscargo test -p loomem-server— 327 unit + integration green (19 new: negotiation matrix, mirrored-header +_metavalidation, base64 sentinel, HTTP status mapping, discover contract)Deviation from the brief: integration fixtures live as in-module tests because
loomem-serverhas no lib target for HTTP-layer tests fromtests/— the same pattern documented intests/feedback_mcp_test.rs.Companion PR: #62 (slice 2, OAuth). Brief: MCP 2026-07-28 adoption (2026-07-29).
🤖 Generated with Claude Code
https://claude.ai/code/session_011gqzVB16MKWKvhDtGQ7Mnn
Greptile Summary
This change adds an MCP 2026-07-28 stateless protocol path, including protocol-version routing, per-request metadata and mirrored-header handling, discovery, and tool-list cache metadata.
Malformed id-less stateless requests are acknowledged with HTTP 202 before mandatory request validation runs. A missing
Mcp-Method, invalid_meta, invalid JSON-RPC version, or unsupported method can therefore appear accepted even though the server has not accepted a valid request.T-Rex validation blocked
The focused Rust MCP test command could not compile because the required
llvm-configtool andlibclangshared library are unavailable. The executed source-based reproduction completed and confirmed the affected control flow.Confidence Score: 4/5
Not safe to merge until id-less stateless requests are validated before they are acknowledged.
The new stateless request path contains a reproduced validation-order failure affecting every id-less request: malformed requests are reported as accepted instead of receiving an HTTP error.
Files Needing Attention: loomem-server/src/mcp/stateless.rs
What T-Rex did
Comments Outside Diff (1)
General comment
MCP-Protocol-Version: 2026-07-28and a parseable JSON-RPC object withoutidreceives HTTP 202 immediately, even whenMcp-Methodis omitted/mismatched, required_metais absent or invalid,jsonrpcis wrong, or the method is unsupported. This silently acknowledges requests that the MCP transport contract says cannot be accepted.loomem-server/src/mcp/stateless.rs:76-80precedes bothvalidate_mirrored_headers(lines 81-83),validate_request_meta(lines 84-86), and router/method validation (line 87).Reviews (5): Last reviewed commit: "fix(mcp): treat an explicit null id as a..." | Re-trigger Greptile